iT邦幫忙

2021 iThome 鐵人賽

DAY 22
0
Mobile Development

Flutter - 複製貼上到開發套件之旅系列 第 22

【第二二天 - Flutter GitHub Search 範例+RxDart+搜尋快取】

  • 分享至 

  • xImage
  •  

前言

今日的程式碼 => GTIHBU

Yes

今天來講講搜尋的介紹。那當然,肯定要以 github 搜尋為範例啦。哈哈哈~~
今天會用到

程式碼參考來自 => RxDart 範例

GithubApi

可以看到,這邊有一個變數叫做 cache,用來暫時儲存快取的資料,是一個 Map 的型態 Map<搜尋的字, 搜尋結果>,當我們要 fetch api 時,可以先去判斷 cache 裡面有沒有這筆資料的值,這樣子。

import 'dart:async';
import 'dart:convert';

import 'package:http/http.dart' as http;

class GithubApi {
  /// url
  final String baseUrl;
  /// 快取,<搜尋的字, 搜尋結果>
  final Map<String, SearchResult> cache;
  /// http client
  final http.Client _client;

  GithubApi({
    http.Client? client,
    Map<String, SearchResult>? cache,
    this.baseUrl = 'https://api.github.com/search/repositories?q=',
  })  : _client = client ?? http.Client(),
        cache = cache ?? <String, SearchResult>{};

  /// Search Github for repositories using the given term
  /// 處理快取
  Future<SearchResult> search(String term) async {
    final cached = cache[term];
    if (cached != null) {
      return cached;
    } else {
      final result = await _fetchResults(term);
      cache[term] = result;
      return result;
    }
  }
  /// 請求 api
  Future<SearchResult> _fetchResults(String term) async {
    final response = await _client.get(Uri.parse('$baseUrl$term'));
    final results = json.decode(response.body);
    return SearchResult.fromJson(results['items']);
  }
}

class SearchResult {
  final List<SearchResultItem> items;

  SearchResult(this.items);

  factory SearchResult.fromJson(dynamic json) {
    final items = (json as List)
        .map((item) => SearchResultItem.fromJson(item))
        .toList(growable: false);

    return SearchResult(items);
  }

  bool get isPopulated => items.isNotEmpty;
  /// 定義 isEmpty,這樣就不用使用 SearchResult.items.isEmpty
  bool get isEmpty => items.isEmpty;
}

class SearchResultItem {
  final String fullName;
  final String url;
  final String avatarUrl;

  SearchResultItem(this.fullName, this.url, this.avatarUrl);

  factory SearchResultItem.fromJson(Map<String, dynamic> json) {
    return SearchResultItem(
      json['full_name'] as String,
      json['html_url'] as String,
      (json['owner'] as Map<String, dynamic>)['avatar_url'] as String,
    );
  }
}

Home

這邊先去 New 一個 GitHubApi 出來,之後用來在 SearchScreen 實作 bloc。

void main() => runApp(SearchApp(api: GithubApi()));

class SearchApp extends StatelessWidget {
  final GithubApi api;

  const SearchApp({Key? key, required this.api}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'RxDart Github Search',
      theme: ThemeData(
        brightness: Brightness.light,
        primarySwatch: Colors.grey,
      ),
      home: SearchScreen(api: api),
    );
  }
}

Search_Bloc

這邊是一個放邏輯的地方。
可以看到我們使用了 RxDart
Rxdart

相信這邊看官網會比較清楚的(ㄅ~~

可以看到我們使用了一個 PublishSubject,他是一個類似 stream 的 broadcast 效果。
下面用到的一些函示都是 RxDart 幫我們整合的函式。

  • distinct()
    • 如果與前一比資料一樣,將不會觸發。
  • debounceTime(const Duration(milliseconds: 250))
    • 等 0.25 秒後,才開始搜尋,執行 api
  • switchMap((String term) => _search(term, api))
    • 這個超重要,搜尋的時候常常會用到。如果輸入 a 的時候,開始搜尋,再輸入 b 後,變成 ab,但是 a 的搜尋結果還沒出來,那麼變成 ab 後,他會把這個還沒搜尋完成的 a 流程給停止掉。避免浪費搜尋時間。
  • startWith(SearchNoTerm())
    • 在這個 stream 的最前面加上一個初始值。

更多的資料可以看 Rx Function 文件

class SearchBloc {
  final Sink<String> onTextChanged;
  final Stream<SearchState> state;

  /// 這邊用 factory 的目的,是為了讓這個 SearchBloc 參數一樣時,物件判定會是一樣的。
  /// 雖然傳遞的參數只有一個,但是實際上我們要創建這個 SearchBloc 需要兩個參數。
  ///
  /// 建構子前以關鍵字 factory 宣告一個工廠建構子,工廠建構子不一定會產生一個新物件,可能回傳一個既存物件。
  /// 要注意工廠建構子在return之前還未有實體,故不能使用this引用成員變數的值或呼叫函數。
  factory SearchBloc(GithubApi api) {
    final onTextChanged = PublishSubject<String>();

    final state = onTextChanged
        // If the text has not changed, do not perform a new search
        // 如果與前一比資料一樣,將不會觸發。
        .distinct()
        // Wait for the user to stop typing for 250ms before running a search
        // 等 0.25 秒後,才開始搜尋,執行 api
        .debounceTime(const Duration(milliseconds: 250))
        // Call the Github api with the given search term and convert it to a
        // State. If another search term is entered, switchMap will ensure
        // the previous search is discarded so we don't deliver stale results
        // to the View.
        // 如果輸入 a 的時候,開始搜尋,再輸入 b 後,變成 ab,但是 a 的搜尋結果還沒出來,那麼變成 ab 後,他會把這個還沒搜尋完成的 a 流程給停止掉。避免浪費搜尋時間。
        .switchMap<SearchState>((String term) => _search(term, api))
        // The initial state to deliver to the screen.
        // 在這個 stream 的最前面加上一個初始值
        .startWith(SearchNoTerm());
    // 這邊已經初始化完成了,在第 15 行、17 行
    return SearchBloc._(onTextChanged, state);
  }

  SearchBloc._(this.onTextChanged, this.state);

  // 給畫面 call 的,好讓這個 dispose 掉。
  void dispose() {
    onTextChanged.close();
  }

  static Stream<SearchState> _search(String term, GithubApi api) => term.isEmpty
      ? Stream.value(SearchNoTerm())
      //  when the future completes, this stream will fire one event, either data or error, and then close with a done-event.
      // Rx.fromCallable,它在偵聽時調用您指定的函數,然後發出從該函數返回的值。這整個 Rx.fromCallable(()=>future) 會是一個 Stream
      : Rx.fromCallable(() => api.search(term))
          .map((result) =>
              result.isEmpty? SearchEmpty() : SearchPopulated(result))
          .startWith(SearchLoading())
          .onErrorReturn(SearchError());
}

事件的狀態 SearchState

class SearchState {}

class SearchLoading extends SearchState {}

class SearchError extends SearchState {}

class SearchNoTerm extends SearchState {}

class SearchPopulated extends SearchState {
  final SearchResult result;

  SearchPopulated(this.result);
}

class SearchEmpty extends SearchState {}

SearchScreen

class SearchScreen extends StatefulWidget {
  final GithubApi api;

  const SearchScreen({Key? key, required this.api}) : super(key: key);

  @override
  SearchScreenState createState() {
    return SearchScreenState();
  }
}

class SearchScreenState extends State<SearchScreen> {
  late final SearchBloc bloc;

  @override
  void initState() {
    super.initState();

    bloc = SearchBloc(widget.api);
  }

  @override
  void dispose() {
    bloc.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<SearchState>(
      stream: bloc.state,
      initialData: SearchNoTerm(),
      builder: (BuildContext context, AsyncSnapshot<SearchState> snapshot) {
        final state = snapshot.requireData;

        return Scaffold(
          body: Stack(
            children: <Widget>[
              Flex(direction: Axis.vertical, children: <Widget>[
                Container(
                  padding: const EdgeInsets.fromLTRB(16.0, 24.0, 16.0, 4.0),
                  child: TextField(
                    decoration: const InputDecoration(
                      border: InputBorder.none,
                      hintText: 'Search Github...',
                    ),
                    style: const TextStyle(
                      fontSize: 36.0,
                      fontFamily: 'Hind',
                      decoration: TextDecoration.none,
                    ),
                    onChanged: bloc.onTextChanged.add,
                  ),
                ),
                Expanded(
                  child: AnimatedSwitcher(
                    duration: const Duration(milliseconds: 300),
                    child: _buildChild(state),
                  ),
                )
              ])
            ],
          ),
        );
      },
    );
  }

  Widget _buildChild(SearchState state) {
    print(state);
    if (state is SearchNoTerm) {
      return const SearchIntro();
    } else if (state is SearchEmpty) {
      return const EmptyWidget();
    } else if (state is SearchLoading) {
      return const LoadingWidget();
    } else if (state is SearchError) {
      return const SearchErrorWidget();
    } else if (state is SearchPopulated) {
      return SearchResultWidget(items: state.result.items);
    }

    throw Exception('${state.runtimeType} is not supported');
  }
}

上一篇
【第二一天 - Flutter Blue 藍芽文件說明】
下一篇
【第二三天 - Flutter iBeacon 官方範例講解(上)】
系列文
Flutter - 複製貼上到開發套件之旅30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言